跳到主要内容

Java JVM学习-类加载机制

类的生命周期

类从被加载到内存中,到被卸载出内存,一共分为以下几步:

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

其实这里 验证(Verification)、准备(Preparation)、解析(Resolution) 统称为链接阶段(Linking)

因为这里讲的是类加载子系统,所以只说明前五种

类加载子系统是什么?

类加载器子系统负责从文件系统或者网络中加载 Class 文件,class 文件在文件开头有特定的文件标识。而 ClassLoader 只负责 class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定。

这个类加载子系统主要分为下面这三块:Loading、Linking、Init

image.png

类的加载阶段

加载是类加载的第一阶段,在这一步中 JVM 规范要求完成了以下三件事:

  • 通过一个类的全限定名来获取定义这个类的二进制字节流。
  • 将这个字节流多代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的 java.lang.Class 对象(就是学习反射时用到的那个 Class 对象,它内部装载着被加载到方法区的这个对象结构),作为方法区这个类的各种数据的访问入口

以上要求其实并不具体,JVM 的具体实现和应用都是比较灵活的。比如:获取这个类的二进制字节流,并没有说从哪获取,怎么获取,于是就有了从压缩包中读取(jar、war、ear)、从网络中获取(Applet)、运行时计算生成(动态代理)。对于不是数组的类的加载,我们可以定义自己的类加载器去控制字节流的获取方式。但是,对于数组类就不一样了,因为数组类本身不是通过类加载器创建的,而是JVM直接创建的。

类加载的渠道

加载类对象也不是非得从 class 文件,也可以从以下几个渠道

  • 从本地系统中直接加载
  • 通过网络获取,典型场景:Web Applet
  • 从 zip 压缩包中读取,成为日后 jar、war 格式的基础
  • 运行时计算生成,使用最多的是:动态代理技术
  • 由其他文件生成,典型场景:JSP 应用从专有数据库中提取 .class 文件,比较少见
  • 从加密文件中获取,典型的防 Class 文件被反编译的保护措施

ClassLoader

参考资料 JVM的类加载机制详解

上面的类加载阶段使用的就是 ClassLoader,这一节只做大致介绍,详细的看下面

class file 加载到 JVM 中,被称为 DNA 元数据模板,放在方法区。

加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是 Class 文件中常量池部分的内存映射)

graph LR Class文件 --> JVM JVM --> 元数据模板

class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到 JVM 当中来根据这个文件实例化出 n 个一模一样的实例。

此过程就要一个运输工具(类装载器 Class Loader),扮演一个快递员的角色。

image.png

一般来说,只有在第一次 主动调用 某个类时才会去进行类加载。如果一个类有父类,会先去加载其父类,然后再加载其自身。

类的验证阶段

目的在于确保 Class 文件的字节流中包含信息符合当前 JVM 要求,保证被加载类的正确性,不会危害 JVM 自身安全。

主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

文件格式验证

验证字节流是否符合 Class 文件格式的规范,能不能被当前 JVM 处理。验证点比较多,比如:是否以魔数 0xCAFEBABE(咖啡宝贝)开头、主次版本号是否在当前 JVM 的处理范围内、常量池的常量是否有不被支持的常量类型、CONSTANT_Utf8_info 类型的常量中是否有不符合 UTF8 编码的数据等等。这个阶段是基于二进制字节流进行验证的,只有这个阶段验证通过了,字节流才能进入内存的方法区储存。

元数据验证

这个阶段主要是对类的元数据信息进行语义分析和校验,保证不存在不符合 Java 语言规范的元数据信息。比如:除了 java.lang.Object 以外的类是否有父类、是否继承了一个不允许被继承的类、非抽象类是否实现了其父类或接口中要求实现的所有方法、是否覆盖了父类的 final 字段等等。

字节码校验

这个阶段通过数据流和控制流分析,确保程序语义是合法的、符合逻辑的。比如:放置和使用操作栈时数据类型保证一致、保证跳转指令不会跳转到方法体以外的字节码指令上、保证方法体中的类型转换是有效的等等。

符号引用校验

这个阶段是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,它发生在解析步骤中,确保解析能正常执行,比如:符号引用中通过字符串描述的全限定名是否能找到对应的类、符号引用中的类字段方法的访问性是否可以访问当前类等等。

类的准备阶段

为类变量(用 static 修饰的变量)分配内存并且设置该类变量的默认初始值,即零值。(不包含用 final 修饰的 static 常量,因为 final 在编译的时候就会给常量分配值,准备阶段会显示初始化)

public class Temp {
private static int a = 1; // 准备阶段为0,在下个阶段,也就是初始化的时候才是1

// 但是如果是 final 修饰的 static 常量在这步就是 1了
private final static int b = 1;
}

上面这样在 Prepare 中给变量设置为默认初始值有什么意义呢?主要作用就是用来申明变量的存在,避免后面初始化时找不到变量的存在,看如下代码

public class Temp {
static {
a = 100;
}

private static int a = 1;

public static void main(String[] args) {
System.out.println(a); // 输出为 1
}
}

可以看到这个静态代码块放在 a 的上面,如果没有这步 Prepare 声明变量存在,在下一步初始化阶段 按照顺序执行代码 会先执行 static 代码块里面的内容(可以看到它在 private static int a 之前给 a 赋值),因为没有声明这个 a 变量而无法继续下去,因此 Prepare 这步单纯就是先把所有的类变量声明出来

但是注意,上面的静态代码块里面虽然可以给 a 赋值,但是却不能引用

public class Temp {
static {
a = 100;
System.out.println(a); // 这里会报错:非法的前向引用
}

private static int a = 1;
}

Prepare 这步不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中

类的解析阶段

参考资料 图文兼备看懂类加载机制的各个阶段,就差你了!

将常量池内的符号引用转换为直接引用的过程。

符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。符号引用于 JVM 内存布局无关。

符号引用的作用是在编译的过程中,JVM 并不知道引用的具体地址,所以用符号引用进行代替,而在解析阶段将会将这个符号引用转换为真正的内存地址。

直接引用:可以是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的。有了直接引用,那么引用的目标必定已经在虚拟机内存中。

直接引用可以理解为:指向类对象变量方法的指针、指向实例的指针和一个间接定位到对象的对象句柄。

符号引用是指在编译时无法确定对象的内存地址,所以必须使用一个符号引用去对应局部变量表中的一个特定位置,然后在解析阶段将该变量的值或引用地址保存回局部变量表中,此后访问该变量值都会从局部变量表对应的位置查找该值;而直接引用是在编译时就可以确定。

类的初始化阶段

到了初始化阶段,用户定义的 Java 程序代码才真正开始执行。在这个阶段,JVM 会根据语句执行顺序对类对象进行初始化,一般来说当 JVM 遇到下面 5 种情况的时候会触发初始化:

1、遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段的时候(被 final 修饰、已在编译器把结果放入常量池的静态字段除外),以及调用一个类的静态方法的时候。

2、使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

3、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4、当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。

5、当使用 JDK1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

而正式初始化开始会做以下事情:

初始化阶段就是执行类构造器方法 <clinit>() 的过程。

<clinit>() 方法不需定义,它是由 javac 编译器自动收集 类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含 static 变量的时候,就会有 <clinit>() 方法,反之,如果类里面没有静态变量、静态方法块,就不会生成 <clinit>()

构造器方法中指令按语句在源文件中出现的顺序执行。(就是说这个 <clinit>() 方法里面的语句是按照静态变量、静态代码块出现的位置顺序执行)

<clinit>() 不同于类的构造器。(关联:构造器是 <init>())如下可看图,下面 a、b 两个静态变量生成的是 <clinit>() 方法,而 c、d 两个成员变量生成的是 <init>() 方法。

image.png

若该类具有父类,JVM 会保证子类的 <clinit>() 执行前,父类的 <clinit>() 已经执行完毕。

虚拟机必须保证一个类的 <clinit>() 方法在多线程下被同步加锁。(就是说在多线程的情况下,类也只会被加载一次)

public class Temp {
static class DeadThread {
static {
if (true) { // 之所以要加个 if 是因为,不加判断 idea 会报错
System.out.println(Thread.currentThread().getName() + "初始化这个类");
for (;;) {} // 写个死循环,避免这个线程退出,从而可以测试到会不会有两个线程同时执行
}

}
}

public static void main(String[] args) {
Runnable runnable = () -> {
System.out.println(Thread.currentThread().getName() + "开始执行");
new DeadThread();
System.out.println(Thread.currentThread().getName() + "结束执行");
};
new Thread(runnable,"线程一").start();
new Thread(runnable,"线程二").start();
}
}

输出为:

线程一开始执行
线程二开始执行
线程一初始化这个类

可知,在多线程的情况下,类也只会被加载一次